Skip to content

Make Perception warning suppression opt-in - #393

Merged
johnnewman-square merged 4 commits into
mainfrom
johnnewman/fix/restore-ios17-perception-warning
Jul 16, 2026
Merged

Make Perception warning suppression opt-in#393
johnnewman-square merged 4 commits into
mainfrom
johnnewman/fix/restore-ios17-perception-warning

Conversation

@johnnewman-square

@johnnewman-square johnnewman-square commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

PRs #389 and #390 automatically suppressed Perception runtime warnings on platforms using native Observation. This restores the warnings by default while preserving an opt-in suppression path.

Summary

  • Add Runtime.Configuration.suppressPerceptionCheckingWhenUsingObservation, defaulting to false
  • Continue normal Store access unless suppression is explicitly enabled
  • Apply suppression only in debug builds on iOS 17+, macOS 14+, tvOS 17+, and watchOS 10+
  • Cover both direct state reads and child Store wrapper access
  • Add tests for the default warning behavior and opt-in suppression

Test plan

  • Run StoreTests on iOS 17.5 — 25 passed
  • Run StoreTests on iOS 16.2 — passed with 8 expected skips
  • Run the ObservableComposition sample app on iOS 17 to ensure the warnings are occurring by default.

Checklist

  • Unit Tests
  • UI Tests (not applicable)
  • Snapshot Tests (not applicable)
  • I have made corresponding changes to the documentation

@johnnewman-square
johnnewman-square marked this pull request as ready for review July 16, 2026 21:46
@johnnewman-square
johnnewman-square requested review from a team as code owners July 16, 2026 21:46
Comment thread Workflow/Sources/RuntimeConfiguration.swift
@johnnewman-square
johnnewman-square force-pushed the johnnewman/fix/restore-ios17-perception-warning branch from b0efb02 to 8b6c0b9 Compare July 16, 2026 22:35
@johnnewman-square
johnnewman-square enabled auto-merge (squash) July 16, 2026 22:44
@johnnewman-square
johnnewman-square merged commit a1ba4ec into main Jul 16, 2026
19 of 20 checks passed
@johnnewman-square
johnnewman-square deleted the johnnewman/fix/restore-ios17-perception-warning branch July 16, 2026 22:50
johnnewman-square added a commit that referenced this pull request Aug 31, 2026
_This description was generated with an agent._

#393 made Perception warning suppression opt-in through
`Runtime.Configuration`, which is the right default — but that opt-in is
designed to be set once at app startup, and a SwiftUI preview has no
equivalent entry point. The canvas instantiates a view directly: no app
delegate, no runtime to configure. A preview cannot opt in for itself
either, because `_PerceptionLocals` are task-locals and a binding placed
around a `#Preview` body-producing closure has gone out of scope by the
time SwiftUI evaluates that body.

The result is that a preview of an `ObservableScreen` on iOS 17+ reports
the warning with no way to act on it. The workarounds available to a
consumer are per-preview boilerplate, or reintroducing
`WithPerceptionTracking` in production view bodies that no longer need
it.

This suppresses the check whenever the process is rendering previews,
which Xcode signals through `XCODE_RUNNING_FOR_PREVIEWS`.

## Summary

- Add `XcodePreviews`, a debug-only namespace that reports whether the
process is rendering previews
- Suppress the check when either the configuration flag is set **or**
the process is rendering previews, at two places: `Store` state reads,
and the render pass that `workflowPreview` drives
- No public API change:
`Runtime.Configuration.suppressPerceptionCheckingWhenUsingObservation`
keeps the exact semantics #393 documented, and remains the only way to
suppress outside of previews
- Both the detection and its consumers are behind `#if DEBUG`, so
release builds are unaffected

### Why key on the process rather than on `Store.preview`

The narrower fix — tag stores built by `Store.preview` and suppress for
those — only covers half the cases.

`Store.preview` and `ObservableScreen.observableScreenPreview` produce a
static store, and tagging works there. But `workflowPreview` hosts a
**real** workflow in a `WorkflowHostingController`, so its stores come
out of the ordinary render path through `make(model:)` and are
indistinguishable from an app's. Any wrapper built on `workflowPreview`
inherits that, and there are such wrappers in the wild.

Keying on the process covers both, and avoids threading a flag through
`scope(...)`'s child-store construction and the `_StoreCollection`
paths.

I also considered defaulting
`suppressPerceptionCheckingWhenUsingObservation` from the environment
instead. That puts a UI heuristic in the core `Workflow` module and
needs `#if DEBUG` around a public property's default value. Keeping it
in `WorkflowSwiftUI` means the read site is already inside `#if DEBUG &&
canImport(Observation)`.

### Why `Store` alone isn't enough

Suppressing at `Store` covers reads a *view* makes. It does not cover
reads a **workflow makes of its own state inside `render`**, which reach
the state accessor directly and never touch a `Store` — so nothing gated
on the flag or on the process could reach them. Any workflow that reads
its own observable state while rendering trips this, which is most of
them; the `ObservableComposition` sample does it in two places.

Those reads are misreported for the same underlying reason: Perception
decides whether it is looking at a SwiftUI view body by walking the call
stack for AttributeGraph frames. `PreviewView`'s representable callbacks
drive a render pass synchronously and are themselves called by SwiftUI,
so those frames are on the stack and every observable read the pass
makes trips the check.

This is unique to previews. The same workflow running in an app renders
off a runtime update rather than a SwiftUI one, so no AttributeGraph
frame is present and the check correctly stays quiet — which is why the
warnings appear in the canvas and nowhere else.

Wrapping both callbacks fixes it. The predicate deciding *when*
suppression applies is lifted out of `Store` into a module-level funnel
at the same time, so that rule lives in one place rather than being
duplicated at each site that needs it.

### On testing

`XcodePreviews.isRunning` reads the process environment once, which a
test cannot vary, so the lookup is factored into a pure function that is
tested directly. The composed predicate is a one-line `||` whose other
operand is already covered by
`test_perceptionRuntimeWarningsCanBeSuppressedWhenUsingObservation`.

`test_isRunning_isFalseInTheTestProcess` exists to protect the
*negative* test. `test_perceptionRuntimeWarningsWhenUsingObservation`
asserts that the warning fires when unsuppressed, and its assertion is
the absence of a Perception failure — so if the test process ever read
as a preview, that test would silently become a vacuous pass.

No test accompanies the `PreviewView` wrap.
`UIViewControllerRepresentableContext` cannot be constructed outside of
SwiftUI, so the callbacks cannot be driven from a test, and the
stack-walk heuristic they work around cannot be reproduced without a
real SwiftUI update.

## Test plan

- [x] `tuist test --path Samples UnitTests` on iPad Air (5th
generation), iOS 26.5 — the 4 new `XcodePreviewsTests` pass
- [x] `test_perceptionRuntimeWarningsWhenUsingObservation` still passes,
i.e. the warning still fires when neither the flag nor a preview applies
- [x]
`test_perceptionRuntimeWarningsCanBeSuppressedWhenUsingObservation`
still passes
- [x] `swift build --target WorkflowSwiftUI` clean; `swiftformat --lint`
clean
- [x] Reproduced in a live canvas before the fix:
`MultiCounterView_Previews` reports `\State.<computed … (Int)>` on every
canvas update, which is `CounterWorkflow.State.count` read from
`CounterWorkflow.render` and `MultiCounterWorkflow.render`
- [x] Confirmed in a live canvas after the fix — silent on canvas
updates, and silent while driving the counters

## Checklist

- [x] Unit Tests
- [x] UI Tests (not applicable)
- [x] Snapshot Tests (not applicable)
- [x] I have made corresponding changes to the documentation
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants